﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

namespace BestHTTP
{
    using BestHTTP.Authentication;
    using BestHTTP.Extensions;

    /// <summary>
    /// 
    /// </summary>
    public sealed class HTTPRequest
    {
        internal static readonly byte[] EOL = { HTTPResponse.CR, HTTPResponse.LF };

        #region Public Properties

        /// <summary>
        /// The original request's Uri.
        /// </summary>
        public Uri Uri { get; private set; }

        /// <summary>
        /// 
        /// </summary>
        public HTTPMethods MethodType { get; private set; }

        /// <summary>
        /// The raw data to send in a POST request. If it set all other fields that added to this request will be ignored.
        /// </summary>
        public byte[] RawData { get; set; }

        /// <summary>
        /// Indicates that the connection should be open after the response received. If its true, then the internal tcp connections will be reused if it's possible. Default value is true.
        /// The default value can be changed in the HTTPManager class. If you make rare request to the server it's should be changed to false.
        /// </summary>
        public bool IsKeepAlive
        {
            get { return isKeepAlive; }
            set
            {
                if (isKeepAlive)
                    throw new NotSupportedException("Changing the IsKeepAlive property while processing the request is not supported.");
                isKeepAlive = value;
            }
        }

        /// <summary>
        /// With this property caching can be enabled/disabled on a per-request basis.
        /// </summary>
        public bool DisableCache
        {
            get { return disableCache; }
            set
            {
                if (Processing)
                    throw new NotSupportedException("Changing the DisableCache property while processing the request is not supported.");
                disableCache = value;
            }
        }

        /// <summary>
        /// If it's true, the callback function will be called every time if we can send out at least one fragment 
        /// </summary>
        public bool UseStreaming
        {
            get { return useStreaming; }
            set
            {
                if (Processing)
                    throw new NotSupportedException("Changing the UseStreaming property while processing the request is not supported.");
                useStreaming = value;
            }
        }

        /// <summary>
        /// Maximum size of a data chunk that we want to receive when streaming is set.
        /// </summary>
        public int StreamFragmentSize
        {
            get{ return streamFragmentSize; }
            set
            {
                if (Processing)
                    throw new NotSupportedException("Changing the StreamFragmentSize property while processing the request is not supported.");

                if (value < 1)
                    throw new System.ArgumentException("StreamFragmentSize must be at least 1.");

                streamFragmentSize = value;
            }
        }

        /// <summary>
        /// The callback function that will be called when a request is fully processed or when any downloaded fragment is available if UseStreaming is true. Can be null for fire-and-forget requests.
        /// </summary>
        public Action<HTTPRequest, HTTPResponse> Callback
        {
            get { return callback; }
            set
            {
                if (Processing)
                    throw new NotSupportedException("Changing the StreamFragmentSize property while processing the request is not supported.");
                callback = value;
            }
        }

        /// <summary>
        /// Called when the current protocol is upgraded to an other. (HTTP => WebSocket for example)
        /// </summary>
        public Action<HTTPRequest, HTTPResponse> OnUpgraded;

        /// <summary>
        /// With this option if reading back the server's response fails, the request will fail and any exceptions can be checked through the Exception property. The default value is True for POST requests, otherwise false.
        /// </summary>
        public bool DisableRetry { get; set; }

        /// <summary>
        /// Indicates that the request is redirected. If a request is redirected, the connection that served it will be closed regardless of the value of IsKeepAlive.
        /// </summary>
        public bool IsRedirected { get; internal set; }

        /// <summary>
        /// The Uri that the request redirected to.
        /// </summary>
        public Uri RedirectUri { get; internal set; }

        /// <summary>
        /// If redirected it's contains the RedirectUri.
        /// </summary>
        public Uri CurrentUri { get { return IsRedirected ? RedirectUri : Uri; } }

        /// <summary>
        /// The response to the query.
        /// <remarks>If an exception occured during reading of the response stream or can't connect to the server, this will be null!</remarks>
        /// </summary>
        public HTTPResponse Response { get; internal set; }

        /// <summary>
        /// It there is an exception while processing the request or response the Response property will be null, and the Exception will be stored in this property.
        /// </summary>
        public Exception Exception { get; internal set; }

        /// <summary>
        /// Any object can be passed with the request with this property. (eq. it can be identified, etc.)
        /// </summary>
        public object Tag { get; set; }

        /// <summary>
        /// The UserName, Password pair that the plugin will use to authenticate to the remote server.
        /// </summary>
        public Credentials Credentials { get; set; }

        /// <summary>
        /// How many redirection supported for this request. The default is int.MaxValue. 0 or a negative value means no redirection supported.
        /// </summary>
        public int MaxRedirects { get; set; }

        /// <summary>
        /// Use Bouncy Castle's code to handle the secure protocol instead of Mono's. You can try to set it true if you receive a "System.Security.Cryptography.CryptographicException: Unsupported hash algorithm" exception.
        /// </summary>
        public bool UseAlternateSSL { get; set; }

        /// <summary>
        /// The request is currently processed. Internal use ONLY!
        /// </summary>
        internal bool Processing { get; set; }

        /// <summary>
        /// How many times redirected.
        /// </summary>
        internal int RedirectCount { get; set; }

        #endregion

        #region Private Properties

        private bool isKeepAlive;
        private bool disableCache;
        private int streamFragmentSize;
        private bool useStreaming;
        private Action<HTTPRequest, HTTPResponse> callback;

        private Dictionary<string, List<string>> Headers { get; set; }
        private UnityEngine.WWWForm FieldsImpl { get; set; }

        #endregion

        #region Constructors

        #region Default Get Constructors

        public HTTPRequest(Uri uri)
            : this(uri, HTTPMethods.Get, HTTPManager.KeepAliveDefaultValue, HTTPManager.IsCachingDisabled, null)
        {
        }

        public HTTPRequest(Uri uri, Action<HTTPRequest, HTTPResponse> callback)
            : this(uri, HTTPMethods.Get, HTTPManager.KeepAliveDefaultValue, HTTPManager.IsCachingDisabled, callback)
        {
        }

        public HTTPRequest(Uri uri, bool isKeepAlive, Action<HTTPRequest, HTTPResponse> callback)
            : this(uri, HTTPMethods.Get, isKeepAlive, HTTPManager.IsCachingDisabled, callback)
        {
        }
        public HTTPRequest(Uri uri, bool isKeepAlive, bool disableCache, Action<HTTPRequest, HTTPResponse> callback)
            : this(uri, HTTPMethods.Get, isKeepAlive, disableCache, callback)
        {
        }

        #endregion

        public HTTPRequest(Uri uri, HTTPMethods methodType, Action<HTTPRequest, HTTPResponse> callback)
            : this(uri, methodType, HTTPManager.KeepAliveDefaultValue, HTTPManager.IsCachingDisabled, callback)
        {
        }

        public HTTPRequest(Uri uri, HTTPMethods methodType, bool isKeepAlive, Action<HTTPRequest, HTTPResponse> callback)
            : this(uri, methodType, isKeepAlive, HTTPManager.IsCachingDisabled, callback)
        {
        }

        public HTTPRequest(Uri uri, HTTPMethods methodType, bool isKeepAlive, bool disableCache, Action<HTTPRequest, HTTPResponse> callback)
        {
            this.Uri = uri;
            this.MethodType = methodType;
            this.IsKeepAlive = isKeepAlive;
            this.DisableCache = disableCache;
            this.Callback = callback;
            this.StreamFragmentSize = 4 * 1024;

            this.DisableRetry = methodType == HTTPMethods.Post;
            this.MaxRedirects = int.MaxValue;
            this.RedirectCount = 0;
        }

        #endregion

        #region Public Field Functions

        /// <summary>
        /// Adds field fieldName with a given string value.
        /// </summary>
        public void AddField(string fieldName, string value)
        {
            if (FieldsImpl == null)
                FieldsImpl = new UnityEngine.WWWForm();

            FieldsImpl.AddField(fieldName, value);
        }

        public void AddBinaryData(string fieldName, byte[] contents)
        {
            if (FieldsImpl == null)
                FieldsImpl = new UnityEngine.WWWForm();

            FieldsImpl.AddBinaryData(fieldName, contents);
        }

        public void SetFields(UnityEngine.WWWForm wwwForm)
        {
            FieldsImpl = wwwForm;
        }

        #endregion

        #region Header Management

        #region General Management

        /// <summary>
        /// Adds a header and value pair to the Headers. Use it to add custom headers to the request.
        /// </summary>
        /// <example>AddHeader("User-Agent', "FooBar 1.0")</example>
        public void AddHeader(string name, string value)
        {
            if (Headers == null)
                Headers = new Dictionary<string, List<string>>();

            List<string> values;
            if (!Headers.TryGetValue(name, out values))
                Headers.Add(name, values = new List<string>(1));

            values.Add(value);
        }

        /// <summary>
        /// Removes any previously added values, and sets the given one.
        /// </summary>
        public void SetHeader(string name, string value)
        {
            if (Headers == null)
                Headers = new Dictionary<string, List<string>>();

            List<string> values;
            if (!Headers.TryGetValue(name, out values))
                Headers.Add(name, values = new List<string>(1));

            values.Clear();
            values.Add(value);
        }

        public bool HasHeader(string name)
        {
            return Headers != null && Headers.ContainsKey(name);
        }

        public string GetFirstHeaderValue(string name)
        {
            if (Headers == null)
                return null;

            List<string> headers = null;
            if (Headers.TryGetValue(name, out headers) && headers.Count > 0)
                return headers[0];

            return null;
        }

        #endregion

        #region Range Headers

        /// <summary>
        /// Sets the Range header to download the content from the given byte position. See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
        /// </summary>
        /// <param name="firstBytePos">Start position of the download.</param>
        public void SetRangeHeader(int firstBytePos)
        {
            SetHeader("Range", string.Format("bytes={0}-", firstBytePos));
        }

        /// <summary>
        /// Sets the Range header to download the content from the given byte position to the given last position. See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
        /// </summary>
        /// <param name="firstBytePos">Start position of the download.</param>
        /// <param name="lastBytePos">The end position of the download.</param>
        public void SetRangeHeader(int firstBytePos, int lastBytePos)
        {
            SetHeader("Range", string.Format("bytes={0}-{1}", firstBytePos, lastBytePos));
        }

        #endregion

        /// <summary>
        /// Writes out the Headers to the stream.
        /// </summary>
        /// <param name="stream"></param>
        private void SendHeaders(BinaryWriter stream)
        {
            SetHeader("Host", CurrentUri.Host);

            if (IsRedirected && !HasHeader("Referer"))
                AddHeader("Referer", Uri.ToString());

            if (!HasHeader("Accept-Encoding"))
                AddHeader("Accept-Encoding", "gzip, deflate, identity");

            if (!HasHeader("Connection"))
                AddHeader("Connection", IsKeepAlive ? "Keep-Alive, TE" : "Close, TE");

            if (!HasHeader("TE"))
                AddHeader("TE", "chunked, identity");

            byte[] entityBody = GetEntityBody();
            int contentLength = entityBody != null ? entityBody.Length : 0;
            if (RawData == null)
            {
                byte[] fieldData = FieldsImpl != null ? FieldsImpl.data : null;

                if (fieldData != null && fieldData.Length > 0 && !HasHeader("Content-Type"))
                    AddHeader("Content-Type", "application/x-www-form-urlencoded");
            }

            if (!HasHeader("Content-Length") && contentLength != 0)
                AddHeader("Content-Length", contentLength.ToString());

            if (Credentials != null)
            {
                switch (Credentials.Type)
                {
                    case AuthenticationTypes.Basic:
                        // With Basic authentication we don't want to wait for a challange, we will send the hash with the first request
                        SetHeader("Authorization", string.Concat("Basic ", Convert.ToBase64String(Encoding.UTF8.GetBytes(Credentials.UserName + ":" + Credentials.Password))));
                        break;

                    case AuthenticationTypes.Unknown:
                    case AuthenticationTypes.Digest:
                        var digest = DigestStore.Get(this.CurrentUri);
                        if (digest != null)
                        {
                            string authentication = digest.GenerateResponseHeader(this);
                            if (!string.IsNullOrEmpty(authentication))
                                SetHeader("Authorization", authentication);
                        }

                        break;
                }
            }


            // Write out the headers to the stream
            foreach (var kvp in Headers)
            {
                byte[] headerName = string.Concat(kvp.Key, ": ").GetASCIIBytes();

                for (int i = 0; i < kvp.Value.Count; ++i)
                {
                    stream.Write(headerName);
                    stream.Write(kvp.Value[i].GetASCIIBytes());
                    stream.Write(EOL);
                }
            }
        }

        /// <summary>
        /// Returns string representation of the headers.
        /// </summary>
        public string DumpHeaders()
        {
            using (var ms = new MemoryStream())
            using (var bw = new BinaryWriter(ms))
            {
                SendHeaders(bw);
                return ms.ToArray().AsciiToString();
            }
        }

        #endregion

        #region Internal Helper Functions

        internal byte[] GetEntityBody()
        {
            return RawData == null ? (FieldsImpl != null ? FieldsImpl.data : null) : RawData;
        }

        internal bool SendOutTo(Stream stream)
        {
            bool success = false;
            try
            {
                var outStream = new BinaryWriter(stream);

                //Method names are always uppercase.
                outStream.Write(string.Format("{0} {1} HTTP/1.1", MethodType.ToString().ToUpper(), CurrentUri.PathAndQuery).GetASCIIBytes());
                outStream.Write(EOL);

                SendHeaders(outStream);

                outStream.Write(EOL);

                byte[] data = RawData != null ? RawData : (FieldsImpl != null ? FieldsImpl.data : null);
                if (data != null && data.Length > 0)
                    outStream.Write(data, 0, data.Length);

                success = true;
            }
            catch
            {}

            return success;
        }

        internal void UpgradeCallback()
        {
            if (Response == null || !Response.IsUpgraded)
                return;

            try
            {
                if (OnUpgraded != null)
                    OnUpgraded(this, Response);
            }
            catch (Exception e)
            {
                UnityEngine.Debug.LogError(string.Format("{0}: {1}", e.Message, e.StackTrace));
            }
        }

        internal void CallCallback()
        {
            try
            {
                if (Callback != null)
                    Callback(this, Response);
            }
            catch (Exception e)
            {
                UnityEngine.Debug.LogError(string.Format("{0}: {1}", e.Message, e.StackTrace));
            }
        }

        internal void FinishStreaming()
        {
            if (Response != null && UseStreaming)
                Response.FinishStreaming();
        }

        #endregion

        public void Send()
        {
            HTTPManager.SendRequest(this);
        }
    }
}